SPB Git forge
28commits 1branches 0releases
7.7 MBsize
maindefault branch
10 days agolast push
Python 66.3% TypeScript 22.7% JavaScript 8.6% HTML 1.4% CSS 0.7%
5.1 KB · 92 lines tsx
Raw Blame History
1import type { Metadata } from 'next';2import Link from 'next/link';3import { notFound } from 'next/navigation';4import { Bars } from '@/components/charts/bars';5import { LineChart } from '@/components/charts/line-chart';6import { WorldMap } from '@/components/charts/world-map';7import { CompanyMiniList, CompanyTable } from '@/components/company/company-table';8import { EventList } from '@/components/events/event-row';9import { CountryChip } from '@/components/ui/badges';10import { Container, Empty, Note, PageHeader, Section, Stat, StatGrid } from '@/components/ui/section';11import { api, ApiError, safe } from '@/lib/api';12import { fmtInt, fmtPctSigned, fmtScore } from '@/lib/format';13import { routes } from '@/lib/site';14import type { CompanyCard, CountryDetailRaw } from '@/lib/types';1516export const revalidate = 300;1718async function load(code: string): Promise<CountryDetailRaw> {19  try {20    return await api.country(code.toUpperCase());21  } catch (e) {22    if (e instanceof ApiError && e.notFound) notFound();23    throw e;24  }25}26export async function generateMetadata({ params }: { params: Promise<{ code: string }> }): Promise<Metadata> {27  const { code } = await params;28  const d = await safe(api.country(code.toUpperCase()));29  if (!d) return { title: 'Country' };30  return { title: `${d.name} — country atlas`, description: `${d.name}: ${Array.isArray(d.companies) ? d.companies.length : d.companies} monitored companies, ${d.events_30d} structured events in 30 days, hiring momentum ${fmtPctSigned(d.hiring_momentum_30d)}.`, alternates: { canonical: `/country/${code.toLowerCase()}` } };31}3233export default async function CountryPage({ params }: { params: Promise<{ code: string }> }) {34  const { code } = await params;35  const d = await load(code);36  const companies: CompanyCard[] = Array.isArray(d.companies) ? d.companies : [];37  const count = Array.isArray(d.companies) ? d.companies.length : d.companies;38  const map = await safe(api.map('events_30d'));39  const buckets = (map?.buckets ?? []).filter((b) => b.country.toUpperCase() === d.code.toUpperCase());40  return (41    <Container wide>42      <PageHeader43        eyebrow={44          <>45            <Link href={routes.countries()} className="hover:text-ink">46              Country atlas47            </Link>48            <span>/</span>49            <CountryChip code={d.code} link={false} />50            {d.region && <span>{d.region}</span>}51          </>52        }53        title={d.name}54        lede={`${fmtInt(count)} monitored companies headquartered in ${d.name}. Activity and hiring are averages over the monitored population.`}55      />56      <StatGrid cols={5}>57        <Stat label="Companies" value={fmtInt(count)} size="sm" />58        <Stat label="Events 7 d" value={fmtInt(d.events_7d)} size="sm" />59        <Stat label="Events 30 d" value={fmtInt(d.events_30d)} size="sm" />60        <Stat label="Activity" value={fmtScore(d.activity_score)} size="sm" />61        <Stat label="Hiring 30 d" value={fmtPctSigned(d.hiring_momentum_30d)} size="sm" />62      </StatGrid>63      <div className="grid gap-8 lg:grid-cols-12">64        <Section eyebrow="Activity" title="Country activity, 90 days" className="lg:col-span-8">65          {d.series?.length > 1 ? <LineChart series={[{ id: 'activity', label: 'Activity score', points: d.series.map((p) => ({ day: p.day, value: p.value })) }]} height={200} yZero /> : <Empty compact title="Not enough history yet." />}66        </Section>67        <Section eyebrow="Industry mix" title="Monitored companies by industry" className="lg:col-span-4">68          {d.industry_mix?.length ? <Bars dense rows={d.industry_mix.slice(0, 10).map((m) => ({ key: m.industry, label: m.industry.replace(/-/g, ' '), value: m.companies, href: routes.industry(m.industry) }))} /> : <Empty compact />}69        </Section>70      </div>71      <div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3">72        <Section eyebrow="Top movers" title="Highest Corporate Change Index">73          <CompanyMiniList items={d.movers ?? []} metric="corporate_change_index" label="CCI" sparkline />74        </Section>75        <Section eyebrow="New entrants" title="Most recently onboarded">76          <CompanyMiniList items={d.new_entrants ?? []} metric="activity_score" label="activity" />77        </Section>78        <Section eyebrow="Map" title="Headquarters clusters">79          {buckets.length ? <WorldMap buckets={buckets} highlight={[d.code]} interactive={false} /> : <Empty compact title="No geocoded clusters yet." />}80        </Section>81      </div>82      <Section eyebrow="Companies" title="Most active monitored companies" action={{ href: routes.companies({ country: d.code }), label: 'All in directory' }}>83        <CompanyTable items={companies} />84      </Section>85      <Section eyebrow="Events" title="Latest structured events" action={{ href: routes.events({ country: d.code }), label: 'All events' }}>86        <EventList events={d.events ?? []} variant="table" />87        <Note className="mt-3">Country attribution uses the company’s headquarters. Expansion events (new offices, new countries) appear under the company’s home country and on the destination country’s timeline where detected.</Note>88      </Section>89    </Container>90  );91}92